diff --git a/cypress/integration/api-tokens.spec.js b/cypress/integration/api-tokens.spec.js --- a/cypress/integration/api-tokens.spec.js +++ b/cypress/integration/api-tokens.spec.js @@ -8,7 +8,7 @@ describe('Test API tokens UI', function() { it('should ask for user to login', function() { - cy.visit(this.Urls.api_tokens(), {failOnStatusCode: false}); + cy.visit(`${this.Urls.oidc_profile()}#tokens`, {failOnStatusCode: false}); cy.location().should(loc => { expect(loc.pathname).to.eq(this.Urls.oidc_login()); }); @@ -29,7 +29,7 @@ // the tested UI should not be accessible for standard Django users // but we need a user logged in for testing it cy.adminLogin(); - cy.visit(Urls.api_tokens()); + cy.visit(`${Urls.oidc_profile()}#tokens`); } function generateToken(Urls, status, tokenValue = '') { diff --git a/swh/web/api/urls.py b/swh/web/api/urls.py --- a/swh/web/api/urls.py +++ b/swh/web/api/urls.py @@ -3,10 +3,6 @@ # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information -from django.conf.urls import url -from django.contrib.auth.decorators import login_required -from django.shortcuts import render - from swh.web.api.apiurls import APIUrls import swh.web.api.views.content # noqa import swh.web.api.views.directory # noqa @@ -21,11 +17,4 @@ import swh.web.api.views.stat # noqa import swh.web.api.views.vault # noqa - -@login_required(login_url="/oidc/login/", redirect_field_name="next_path") -def _tokens_view(request): - return render(request, "api/tokens.html") - - urlpatterns = APIUrls.get_url_patterns() -urlpatterns.append(url(r"^tokens/$", _tokens_view, name="api-tokens")) diff --git a/swh/web/assets/src/bundles/auth/index.js b/swh/web/assets/src/bundles/auth/index.js --- a/swh/web/assets/src/bundles/auth/index.js +++ b/swh/web/assets/src/bundles/auth/index.js @@ -169,7 +169,7 @@ }); } -export function initApiTokensPage() { +export function initProfilePage() { $(document).ready(() => { apiTokensTable = $('#swh-bearer-tokens-table') .on('error.dt', (e, settings, techNote, message) => { @@ -212,5 +212,12 @@ scrollY: '50vh', scrollCollapse: true }); + $('#swh-oidc-profile-tokens-tab').on('shown.bs.tab', () => { + apiTokensTable.draw(); + window.location.hash = '#tokens'; + }); + if (window.location.hash === '#tokens') { + $('.nav-tabs a[href="#swh-oidc-profile-tokens"]').tab('show'); + } }); } diff --git a/swh/web/auth/views.py b/swh/web/auth/views.py --- a/swh/web/auth/views.py +++ b/swh/web/auth/views.py @@ -13,6 +13,7 @@ from django.conf.urls import url from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.decorators import login_required from django.core.cache import cache from django.core.paginator import Paginator from django.http import HttpRequest @@ -23,6 +24,7 @@ HttpResponseServerError, JsonResponse, ) +from django.shortcuts import render from django.views.decorators.http import require_http_methods from swh.web.auth.models import OIDCUser, OIDCUserOfflineTokens @@ -216,6 +218,11 @@ return HttpResponse(status=401) +@login_required(login_url="/oidc/login/", redirect_field_name="next_path") +def _oidc_profile_view(request: HttpRequest) -> HttpResponse: + return render(request, "auth/profile.html") + + urlpatterns = [ url(r"^oidc/login/$", oidc_login, name="oidc-login"), url(r"^oidc/login-complete/$", oidc_login_complete, name="oidc-login-complete"), @@ -240,4 +247,5 @@ oidc_revoke_bearer_tokens, name="oidc-revoke-bearer-tokens", ), + url(r"^oidc/profile/$", _oidc_profile_view, name="oidc-profile",), ] diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py --- a/swh/web/common/utils.py +++ b/swh/web/common/utils.py @@ -260,6 +260,7 @@ "swh_client_config": config["client_config"], "oidc_enabled": bool(config["keycloak"]["server_url"]), "browsers_supported_image_mimes": browsers_supported_image_mimes, + "keycloak": config["keycloak"], } diff --git a/swh/web/templates/api/tokens.html b/swh/web/templates/api/tokens.html deleted file mode 100644 --- a/swh/web/templates/api/tokens.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "layout.html" %} - -{% comment %} -Copyright (C) 2020 The Software Heritage developers -See the AUTHORS file at the top-level directory of this distribution -License: GNU Affero General Public License version 3, or any later version -See top-level LICENSE file for more information -{% endcomment %} - -{% load render_bundle from webpack_loader %} -{% load swh_templatetags %} - -{% block title %} Web API bearer tokens – Software Heritage API {% endblock %} - -{% block header %} -{% render_bundle 'auth' %} -{% endblock %} - -{% block navbar-content %} -<h4>Web API bearer tokens management</h4> -{% endblock %} - -{% block content %} - -<p> - That interface enables to manage bearer tokens for Web API authentication. - A token has to be sent in HTTP authorization headers to make authenticated API requests. -</p> -<p> - For instance when using <code>curl</code> proceed as follows: - <pre>curl -H "Authorization: Bearer ${TOKEN}" {{ request.scheme }}://{{ request.META.HTTP_HOST }}/api/...</pre> -</p> - -<div class="mt-3"> - <div class="float-right"> - <button class="btn btn-default" onclick="swh.auth.applyTokenAction('generate')"> - Generate new token - </button> - <button class="btn btn-default float-right" onclick="swh.auth.applyTokenAction('revokeAll')"> - Revoke all tokens - </button> - </div> - <table id="swh-bearer-tokens-table" class="table swh-table swh-table-striped" width="100%"> - <thead> - <tr> - <th>Creation date</th> - <th>Actions</th> - </tr> - </thead> - </table> -</div> - -<script> - swh.auth.initApiTokensPage(); -</script> - -{% endblock content %} \ No newline at end of file diff --git a/swh/web/templates/auth/profile.html b/swh/web/templates/auth/profile.html new file mode 100644 --- /dev/null +++ b/swh/web/templates/auth/profile.html @@ -0,0 +1,74 @@ +{% extends "layout.html" %} + +{% comment %} +Copyright (C) 2020 The Software Heritage developers +See the AUTHORS file at the top-level directory of this distribution +License: GNU Affero General Public License version 3, or any later version +See top-level LICENSE file for more information +{% endcomment %} + +{% load render_bundle from webpack_loader %} +{% load swh_templatetags %} + +{% block title %} User profile – Software Heritage {% endblock %} + +{% block header %} +{% render_bundle 'auth' %} +{% endblock %} + +{% block navbar-content %} +<h4>User profile</h4> +{% endblock %} + +{% block content %} + +<ul class="nav nav-tabs" style="padding-left: 5px;"> + <li class="nav-item"> + <a class="nav-link active" data-toggle="tab" id="swh-oidc-profile-settings-tab" href="#swh-oidc-profile-settings">Settings</a> + </li> + <li class="nav-item"> + <a class="nav-link" data-toggle="tab" id="swh-oidc-profile-tokens-tab" href="#swh-oidc-profile-tokens">API tokens</a> + </li> +</ul> + +<div class="tab-content"> + <div id="swh-oidc-profile-settings" class="tab-pane active"> + <iframe id="swh-oidc-profile-settings-iframe" style="min-width: 100%; height: 90vh; border: none;" + src="{{ keycloak.server_url }}realms/{{ keycloak.realm_name }}/account/?swh-web-user-profile"></iframe> + </div> + + <div id="swh-oidc-profile-tokens" class="tab-pane"> + <p class="mt-3"> + That interface enables to manage bearer tokens for Web API authentication. + A token has to be sent in HTTP authorization headers to make authenticated API requests. + </p> + <p> + For instance when using <code>curl</code> proceed as follows: + <pre>curl -H "Authorization: Bearer ${TOKEN}" {{ request.scheme }}://{{ request.META.HTTP_HOST }}/api/...</pre> + </p> + <div class="mt-3"> + <div class="float-right"> + <button class="btn btn-default" onclick="swh.auth.applyTokenAction('generate')"> + Generate new token + </button> + <button class="btn btn-default float-right" onclick="swh.auth.applyTokenAction('revokeAll')"> + Revoke all tokens + </button> + </div> + <table id="swh-bearer-tokens-table" class="table swh-table swh-table-striped" width="100%"> + <thead> + <tr> + <th>Creation date</th> + <th>Actions</th> + </tr> + </thead> + </table> + </div> + </div> +</div> + +<script> + swh.auth.initProfilePage(); +</script> + +{% endblock content %} diff --git a/swh/web/templates/layout.html b/swh/web/templates/layout.html --- a/swh/web/templates/layout.html +++ b/swh/web/templates/layout.html @@ -1,5 +1,5 @@ {% comment %} -Copyright (C) 2015-2019 The Software Heritage developers +Copyright (C) 2015-2020 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information @@ -26,7 +26,7 @@ /* @licstart The following is the entire license notice for the JavaScript code in this page. -Copyright (C) 2015-2019 The Software Heritage developers +Copyright (C) 2015-2020 The Software Heritage developers This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as @@ -92,10 +92,12 @@ <li class="swh-position-right"> {% url 'logout' as logout_url %} {% if user.is_authenticated %} - Logged in as <strong>{{ user.username }}</strong>, + Logged in as {% if 'OIDC' in user.backend %} + <a href="{% url 'oidc-profile' %}"><strong>{{ user.username }}</strong></a>, <a href="{% url 'oidc-logout' %}">logout</a> {% else %} + <strong>{{ user.username }}</strong>, <a href="{{ logout_url }}">logout</a> {% endif %} {% elif oidc_enabled %} diff --git a/swh/web/tests/auth/test_views.py b/swh/web/tests/auth/test_views.py --- a/swh/web/tests/auth/test_views.py +++ b/swh/web/tests/auth/test_views.py @@ -16,6 +16,7 @@ from swh.web.auth.models import OIDCUser, OIDCUserOfflineTokens from swh.web.auth.utils import OIDC_SWH_WEB_CLIENT_ID from swh.web.common.utils import reverse +from swh.web.config import get_config from swh.web.tests.django_asserts import assert_contains from swh.web.tests.utils import ( check_html_get_response, @@ -525,3 +526,33 @@ status_code=401, data={"password": "invalid-password", "token_ids": [1]}, ) + + +def test_oidc_profile_view_anonymous_user(client): + """ + Non authenticated users should be redirected to login page when + requesting profile view. + """ + url = reverse("oidc-profile") + login_url = reverse("oidc-login", query_params={"next_path": url}) + resp = check_html_get_response(client, url, status_code=302) + assert resp["location"] == login_url + + +@pytest.mark.django_db +def test_oidc_profile_view(client, mocker): + """ + Authenticated users should be able to request the profile page + and link to Keycloak account UI should be present. + """ + url = reverse("oidc-profile") + kc_config = get_config()["keycloak"] + mock_keycloak(mocker) + client.login(code="", code_verifier="", redirect_uri="") + resp = check_html_get_response( + client, url, status_code=200, template_used="auth/profile.html" + ) + kc_account_url = ( + f"{kc_config['server_url']}realms/{kc_config['realm_name']}/account/" + ) + assert_contains(resp, kc_account_url)